5

前前后后,大概两个月的时间,lunar这个项目终于达到了一个很高的完整度。

Lunar是一个Python语言的网络框架,类似于Django,Flask,Tornado等当下流行的web framework。最初有这个想法是在大二下学期,当时接触Python web编程有一段时间。最早接触Python web编程或许是在大一下?自觉当时编程还没有入门,第一个接触的web框架是Django,很庞大的框架,当时很low逼的去看那本Django Book的中文译本,翻译的其实很不错了,只是进度会落后于当前的版本,所以在跑example的时候会有一些问题,Django的庞大规模给我留下了很大的心理阴影,所以在之后,对于涉世未深的Pythoner,我可能都不会推荐Django作为第一个Python的网络框架来学习。

整个框架的挑战还是非常大的。核心的几个组件(模板引擎,ORM框架,请求和应答的处理)还是有一些难度,但是经过一步步的分析和编码还是能够完成功能。项目受Flask非常大的影响,最初作为造轮子的初衷,几乎完整的使用了Flask和SQLAlchemy的API接口。

项目同样开源在Github上: https://github.com/jasonlvhit/lunar

也可以通过pip直接安装:

$ pip install lunar

这里我大概的记述一下lunar整个项目的各个组件的设计和实现。

ORM framework

首先是ORM。

python圈子里面还是有很多很著名的orm框架,SQLAlchemypeeweepony orm各有特色,SQLAlchemy和peewee都已經是很成熟的框架,大量的被应用在商业环境中。回到Lunar,既然是造轮子,何不造个彻底,于是便撸了一个orm框架出来。

在ORM框架中,我们使用类的定义来表示数据库中的表结构,使用类方法避免繁琐的SQL语句,一个ORM类定义类似于下面这段代码:

pyclass Post(db.Model):

    __tablename__ = 'post'

    id = database.PrimaryKeyField()
    title = database.CharField(100)
    content = database.TextField()
    pub_date = database.DateField()

    author_id = database.ForeignKeyField('author')
    tags = database.ManyToManyField(rel='post_tag_re', to_table='tag')

    def __repr__(self):
        return '<Post %s>' % self.title

上面这段代码取自Lunar框架的ORM测试和一个博客的example,这段代码定义了一个Post类,代表了数据库中的一张表,类中的一系列属性分别对应着表中的列数据。

一个peewee或者SQLAlchemy类似语法的一个ORM框架语句类似下面这样:

py    p = Post.get(id=1)

返回的结果是Post类的实例,这个实例,p.id返回的不是一个PrimaryKeyField,而是一个int类型值,其他的与数据库关联的类属性也是同样。

orm框架本质上是sql语句或者数据库模式(schema)和python对象之间的转换器或者翻译器,这有些类似于编译器结构。

在这里,Post是我们创建的一个orm类,post拥有若干数据操作方法,通过调用类似这样的更加人性化或者直观的api,代替传统的sql语句和对象映射。orm框架将语句翻译为sql语句,执行,并在最后将语句转换为post类的实例。

可能从这个角度看来,实现orm框架并不是什么tough的任务,让我们用上面提到的这个例子来看

py p = Post.get(id=1)

这条语句翻译成的sql语句为

select * from post where id=1;

可以看到的是,get方法会使用一个select语句,翻译程序将post类的表名和条件分别组合到select语句中,嗅觉灵敏的pythoner会发现这是一个典型的Post类的classmethod,直接通过类来调用这个方法,我们可以快速的写出这个函数的伪代码:

pyclass Model(Meta):

    ...

    @classmethod
    def get(cls, *args, **kwargs):
        # get method only supposes to be used by querying by id.
        # UserModel.get(id=2)
        # return a single instance.
        sql = "select * from %s"
        if kwargs:
            sql += "where %s"
        rs = db.execute(sql %(cls.__tablename__, ' and '.join(['='.join([k, v]) 
            for k, v in kwargs.items()])))
        return make_instance(rs, descriptor) #descriptor describe the format of rs.

从本质上,所有的翻译工作都可以这样来完成。但是在重构后的代码中可能会掩盖掉很多细节。

其实這大概是实现一个orm框架的全部了,只是我们还需要一点python中很酷炫的一个编程概念来解决一个问题。

考虑实现ORM框架的create_all方法,创建所有ORM框架规范下类的实际数据库表,这是熟悉SQLAlchemy的Pythoner都会比较熟悉的一个方法。

create_all方法要求所有继承了db.Model类的子类全部注册在db的一个属性中,比如tabledict,这样create_all方法在调用时可以使用db中的tabledict属性,将所有注册的类编译为SQL语句并执行。

直观的来看,我们需要控制类创建的行为。例如Post类,在这个类被创建的时候,将Post类写入tabledict

那么怎么控制一个类被创建的时候的行为?答案是使用元编程,Python中有多种实现元编程的方式,descriptor或者metaclass等方式都是实现元编程的方式,在这里,我们使用元类(metaclass)。关于metaclass,网络上最经典的文章莫过于StackOverflow上的这篇回答,强烈推荐给所有的人看。这里我先直接给出伪码:

pyclass MetaModel(type):
    def __new__(cls, name, bases, attrs):
        cls = super(MetaModel, cls).__new__(cls, name, bases, attrs)

        ...

        cls_dict = cls.__dict__
        if '__tablename__' in cls_dict.keys():
            setattr(cls, '__tablename__', cls_dict['__tablename__'])
        else:
            setattr(cls, '__tablename__', cls.__name__.lower())

        if hasattr(cls, 'db'):
            getattr(cls, 'db').__tabledict__[cls.__tablename__] = cls

        ...

        return cls

class Model(MetaModel('NewBase', (object, ), {})): #python3 compatibility
    def __init__(self, **kwargs):

        ...

        for k, v in kwargs.items():
            setattr(self, k, v))

        ...

这种方式定义的Model,在创建的时候,会由MetaModel控制创建过程,最后返回整个类,在创建过程中,我们将表名称和类本身全部塞入了db的一个属性中。这样create_all方法便可以直接使用tabledict中的类属性直接创建所有的表:

pyclass Database(threading.local):
    ...

    def create_all(self):
        for k, v in self.__tabledict__.items():
            if issubclass(v, self.Model):
                self.create_table(v)

OK,到这里,几乎ORM的所有核心技术全部介绍完毕。ORM并不是一个很tough的工作,但是也并不是很简单。ORM框架的实现是一个解决一系列问题的过程,其实思考的过程是最为激动人心的。

模板引擎

模板引擎是另外一个比较大和tough的模块。Python同样有很多出色的模板引擎,当下最为流行莫过于MakoJinja2,国外的Reddit和国内的豆瓣公司大量的使用了Mako作为模板引擎进行网页渲染。Jinja2因为具有强大的性能支撑和沙箱模式,在Python社区中也很流行。

Python模板引擎的核心功能是把标记语言编译成为可执行的代码,执行一些逻辑或者操作,返回模板文件的渲染结果,往往是字符串。模板引擎的实现同样类似于传统的编译器结构,模板引擎首先会使用一个词法分析模块分析出所有的token,并分类标记;在这之后,会使用一个类似于编译器中的语法分析的模块分析token序列,调用相应的操作,对于不同的token,我们需要单独编写一个处理程序(类似于SDT),来处理token的输出。

最简单的例子:

py    t = lunar.Template('Hello {{ name }}').render(name='lunar')

这段代码,我们期待的输出是"Hello lunar",name会被lunar替换掉。根据上面我提到的模板引擎的工作流程,首先,我们使用词法分析程序对这段模板语言做模板编译,分割所有的字符串(实际实现的时候并非如此),给每个单词赋给一个属性,例如上面这段模板语言经过最基础的词法分析会得到下面这个结果:

<'Hello' PlainText>
<' ' Operator> # Blank Space
<'name' Variable> 

有了“词法分析”得到的序列,我们开始遍历这个序列中的所有token,对每一个token进行处理。

pyfor token in tokens:
    if isinstance(token, PlainText):
        processPlainText(token)
    elif isinstance(token, Variable):
        processVariable(token)

    ...

一些模板引擎将模板标记语言编译为Python代码,使用exec函数执行,最后将结果嵌套回来。例如上面这段代码,我们可以依次对token进行类似下面这样的处理:

pydef processPlainText(token):
    return "_stdout.append('" +token+ "')"

def processVariable(token):
    return "_stdout.append(" + token +")"

看到这里你可能会觉得莫名其妙,对于一连串的token序列,经过处理后的字符串类似于下面这样,看完后你的状态肯定还是莫名其妙:

pyintermediate = ""
intermediate += "_stdout.append('Hello')"
intermediate += "_stdout.append(' ')"
intermediate += "_stdout.append(name)"

回到上面提到的那个函数exec,我们使用exec函数执行上面的这段字符串,这在本质上其实是一种很危险的行为。exec函数接受一个命名空间,或者说上下文(context)参数,我们对这段代码做类似下面的处理:

pycontext = {}
context['_stdout'] = []
context['name'] = 'lunar'

exec(intermediate, context)

return ''.join(context['_stdout'])

context是一个字典,在真正的模板渲染时,我们把所有需要的上下文参数全部update到context中,传给exec函数进行执行,exec函数会在context中进行更改,最后我们可以取到context中经过修改后的所有的值。在这里,上面两个代码片段中的_stdout在context中作为一个空列表存在,所以在执行完exec后,context中的stdout会带回我们需要的结果。

具体来看,将render中的context传入exec,这里exec会执行一个变换:

_stdout.append(name) -> exec(intermediate, {name:'lunar'}) -> _stdout.append('lunar')

经过这个神奇的变化之后(搞毛,就是替换了一下嘛),我们就得到了模板渲染后需要的结果,一个看似是标记语言执行后的结果。

让我们看一个稍微复杂一些的模板语句,比如if...else...,经过处理后的中间代码会类似于下面这样:

<html>
{% if a > 2 %}
    {{ a }}
{% else %}
    {{ a * 3 }}
{% endif %}
</html>

中间代码:

pyintermediate = "_stdout.append('<html>')\n"
intermediate += "if a > 2 :\n"
intermediate += "   _stdout.append(a)\n"
intermediate += "else :\n"
intermediate += "   _stdout.append(a * 3)\n"
intermediate += "_stdout.append('</html>')"

注意中间代码中的缩进!这是这一类型的模板引擎执行控制流的所有秘密,这段代码就是原生的Python代码,可执行的Python代码。模板引擎构建了一个从标记语言到Python原生语言的转换器,所以模板引擎往往能够做出一些看似很吓人,其实很low的功能,比如直接在模板引擎中写lambda函数:

pyfrom lunar.template import Template

rendered = Template(
            '{{ list(map(lambda x: x * 2, [1, 2, 3])) }}').render()

但是!深入优化后的模板引擎往往没有这么简单,也不会使用这么粗暴的实现方式,众多模板引擎选择了自己写解释程序。把模板语言编译成AST,然后解析AST,返回结果。这样做有几点好处:

  • 自定义模板规则
  • 利于性能调优,比如C语言优化

当然,模板引擎界也有桑心病狂者,使用全部的C来实现,比如同样很有名的Cheetah。

或许因为代码很小的原因,我在Lunar中实现的这个模板引擎在多个benchmark测试下展现了还不错的性能,具体的benchmark大家可以在项目的template测试中找到,自己运行一下,这里给出一个基于我的机器的性能测试结果:

第一个结果是Jonas BorgstrOm为SpitFire所写的benchmarks

Linux Platform
-------------------------------------------------------
Genshi tag builder                            239.56 ms
Genshi template                               133.26 ms
Genshi template + tag builder                 261.40 ms
Mako Template                                  44.64 ms
Djange template                               335.10 ms
Cheetah template                               29.56 ms
StringIO                                       33.63 ms
cStringIO                                       7.68 ms
list concat                                     3.25 ms
Lunar template                                 23.46 ms
Jinja2 template                                 8.41 ms
Tornado Template                               24.01 ms
-------------------------------------------------------

Windows Platform
-------------------------------------------------------
Mako Template                                 209.74 ms
Cheetah template                              103.80 ms
StringIO                                       42.96 ms
cStringIO                                      11.62 ms
list concat                                     4.22 ms
Lunar template                                 27.56 ms
Jinja2 template                                27.16 ms
-------------------------------------------------------

第二个结果是Jinja2中mitsuhiko的benchmark测试:

    Linux Platform:
    ----------------------------------
    jinja               0.0052 seconds
    mako                0.0052 seconds
    tornado             0.0200 seconds
    django              0.2643 seconds
    genshi              0.1306 seconds
    lunar               0.0301 seconds
    cheetah             0.0256 seconds
    ----------------------------------

    Windows Platform:
    ----------------------------------
    ----------------------------------

    jinja               0.0216 seconds
    mako                0.0206 seconds
    tornado             0.0286 seconds
    lunar               0.0420 seconds
    cheetah             0.1043 seconds
    -----------------------------------

这个结果最吸引我的有下面几点:

  • Jinja2真(TM)快!
  • Django真慢!
  • Mako的实现肯定有特殊的优化点,不同的benchmark差距过大!

现在Lunar的代码还很脏,而且可以重构的地方还很多,相信重构后性能还会上一个台阶(谁知道呢?)。

Router

Router负责整个web请求的转发,将一个请求地址和处理函数匹配在一起。主流的Router有两种接口类型,一种是Django和Tornado类型的"字典式":

pyurl_rules = {
    '/': index,
    '/post/\d': post,
}

另外一种是Flask和Bottle这种小型框架偏爱的装饰器(decorator)类型的router:

py@app.route('/')
def index():
    pass

@app.route('/post/<int:id>')
def post(id):
    pass

router的实现还是很简单的,router的本质就是一个字典,把路由规则和函数连接在一起。这里有一些麻烦的是处理带参数的路由函数,例如上例中,post的id是可以从路由调用地址中直接获得的,调用/post/12会调用函数post(12),在这里,传参是较为麻烦的一点。另外的一个难点是redirect和url_for的实现:

py return redirect(url_for(post, 1))

但其实也不难啦,感兴趣的可以看一下代码的实现。

Router的另外一个注意点是,使用装饰器方式实现的路由需要在app跑起来之前,让函数都注册到router中,所以往往需要一些很奇怪的代码,例如我在Lunar项目的example中写了一个blog,blog的init文件是像下面这样定义的:

pyfrom lunar import lunar
from lunar import database

app = lunar.Lunar('blog')
app.config['DATABASE_NAME'] = 'blog.db'

db = database.Sqlite(app.config['DATABASE_NAME'])

from . import views

注意最后一行,最后一行代码需要import views中的所有函数,这样views中的函数才会注册到router中。这个痛点在Flask中同样存在。

WSGI

最后的最后,我们实现了这么多组件,我们还是需要来实现Python请求中最核心和基本的东西,一个WSGI接口:

pydef app(environ, start_response):
     start_response('200 OK', [('Content-Type', 'text/plain')])
     yield "Hello world!\n"

WSGI接口很简单,实现一个app,接受两个参数environ和start_response,想返回什么就返回什么好了。关于WSGI的详细信息,可以查看PEP333PEP3333。这里我说几点对WSGI这个东西自己的理解:

计算机服务,或者说因特网服务的核心是什么?现在的我,会给出协议这个答案。我们会发现,计算机的底层,网络通信的底层都是很简单、很朴素的东西,无非是一些0和1,一些所谓字符串罢了。去构成这些服务,把我们连接在一起的是我们解释这些朴素的字符串的方式,我们把它们称为协议

WSGI同样是一个协议,WSGI最大的优势是,所有实现WSGI接口的应用均可以运行在WSGI server上。通过这种方式,实现了Python WSGI应用的可移植。Django和Flask的程序可以混编在一起,在一个环境上运行。在我实现的框架Lunar中,使用了多种WSGI server进行测试。

在一些文章中,把类似于router,template engine等组件,包装在网络框架之中,WSGI应用之上的这些组件成为WSGI中间件,得益于WSGI接口的简单,编写WSGI中间件变得十分简单。在这里,最难的问题是如何处理各个模块的解耦。

考虑之前提到的模板引擎和ORM framework的实现,模板引擎和数据库ORM都需要获取应用的上下文(context),这是实现整个框架的难点。也是项目未来重构的核心问题。

现在代码之烂是让我无法忍受的。最近开始读两本书,代码整洁之道重构,自己在处理大型的软件体系,处理很多设计模式的问题的时候还是很弱逼。首先会拿模板引擎开刀,我有一个大体重构的方案,会很快改出来,力争去掉parser中的大条件判断,并且尝试做一些性能上的优化。

Lunar是一个学习过程中的实验品,这么无聊,总是要写一些代码的,免得毕业后再失了业。

在最后,还是要感谢亮叔https://github.com/skyline75489,亮叔是我非常崇拜的一个Pythoner,或者说coder,一名天生的软件工匠。没有他这个项目不会有这么高的完整度,他改变了我对这个项目的态度。


jasonlvhit
128 声望2 粉丝